import datetime
from typing import Union, Tuple, List, Optional


def date_to_datetime(
    dt: datetime.date, t: Union[str, datetime.time] = "begin"
) -> datetime.datetime:
    """Convert a `datetime.date` object to `datetime.datetime`, optionally with a given time

    Parameters
    ----------
    dt : datetime.date
        The source `datetime.date` object.
    t : Union[str, datetime.time], optional
        The source time component, either a `datetime.time` object, or a string shortcut.
        Shortcuts "begin" (00:00:00), "mid" (12:00:00), "end" (23:59:59.999999) are supported.
        Default is "begin".

    Returns
    -------
    datetime.datetime
        The converted `datetime.datetime` object.

    Raises
    ------
    ValueError
        Raised when `t` is a string that is not a known shortcut,
        or `t` is neither a string nor a `datetime.time`.
    """
    if isinstance(t, str):
        if t == "begin":
            t = datetime.time(0, 0, 0)
        elif t == "mid":
            t = datetime.time(12, 0, 0)
        elif t == "end":
            t = datetime.time(23, 59, 59, 999999)
        else:
            raise ValueError("Unknown shortcut {} for time".format(t))
    elif isinstance(t, datetime.time):
        pass
    else:
        raise ValueError(
            "Cannot handle given {}, only support `datetime.time` or a string shortcut".format(
                t
            )
        )
    return datetime.datetime.combine(dt, t)


def datestr_to_datetime(dt: Union[str, datetime.datetime]) -> datetime.datetime:
    """Parse a string representation of a date into a `datetime.datetime` object

    Parameters
    ----------
    dt : str or `datetime.datetime`
        A string representation of a date, or a `datetime.datetime` object.
        If a string, needs to be following one of the formats: "%Y-%m-%d" or "%Y%m%d".

    Returns
    -------
    datetime.datetime
        Parsed date if `dt` is a string, or `dt` itself if `dt` is a `datetime.datetime` object.

    Raises
    ------
    ValueError
        If the given `dt` is not a valid string representation of a date, or not a supported type.
    """
    if isinstance(dt, datetime.datetime):
        return dt
    elif isinstance(dt, str):
        try:
            if "-" in dt:
                dt = datetime.datetime.strptime(dt, "%Y-%m-%d")
            else:
                dt = datetime.datetime.strptime(dt, "%Y%m%d")
        except Exception:
            raise ValueError("Not able to convert string {} to a datetime".format(dt))
        else:
            return dt
    else:
        raise ValueError("Only support `dt` as a string representation of a date")


def datetimestr_to_datetime(dt: Union[str, datetime.datetime]) -> datetime.datetime:
    """Parse a string representation of a date-time into a `datetime.datetime` object

    Parameters
    ----------
    dt : str or `datetime.datetime`
        A string representation of a date-time, or a `datetime.datetime` object.
        If a string, needs to be following one of the formats:
          - "%Y-%m-%dT%H:%M:%S.fZ"
          - "%Y-%m-%dT%H:%M:%SZ"
          - "%Y-%m-%dT%H:%M:%S.f"
          - "%Y-%m-%dT%H:%M:%S"
          - "%Y%m%dT%H%M%S"

    Returns
    -------
    datetime.datetime
        Parsed date if `dt` is a string, or `dt` itself if `dt` is a `datetime.datetime` object.

    Raises
    ------
    ValueError
        If the given `dt` is not a valid string representation of a date-time, or not a supported type.
    """
    if isinstance(dt, datetime.datetime):
        return dt
    elif isinstance(dt, str):
        try:
            if ("-" not in dt) and (":" not in dt):
                dt = datetime.datetime.strptime(dt, "%Y%m%dT%H%M%S")
            elif "Z" in dt:
                if "." in dt:
                    dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%fZ")
                else:
                    dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%SZ")
            else:
                if "." in dt:
                    dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S.%f")
                else:
                    dt = datetime.datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S")
        except Exception:
            raise ValueError(f"Not able to convert string {dt} to a datetime")
        else:
            return dt
    else:
        raise ValueError(f"Only support {dt} as a string representation of a datetime")


"""
DateRangeGenerator generates date range list.
"""


class DateRangeGenerator:
    def __init__(self) -> None:
        pass

    @staticmethod
    def generate_daterange_with_deltadate(
        year: int, delta_date: int, drop_last=False
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """
        Notice! The result of this function is right bound exclusive (end date exclusive)!

        Generate daterange list with given year and deltadate.
        The generated date range is aligned with Modis delta_date days.
        Which means the delta_date between start datetime and first day of year is
        a multiple of delta_date.

        That is, for generated result: List[date_start, date_end]
        for each date_start:
            (date_start - datetime.datetime(year, 1, 1)) % delta_date = 0


        Parameters
        ----------
        year : int
            Which year to be generated
        delta_date : int
            Delta date for each time period
        drop_last : bool, optional
            Whether to drop the last time slot of the year if end date of time slot is in the next year,
             by default False

        Returns
        -------
        List[Tuple[datetime.datetime, datetime.datetime]]
            List of generated date ranges.
        """

        cur_start_date = datetime.datetime(year, 1, 1)
        date_end = datetime.datetime(year + 1, 1, 1)
        time_delta = datetime.timedelta(delta_date)

        result = []
        while cur_start_date < date_end:
            cur_end_date = cur_start_date + time_delta
            if drop_last and cur_end_date >= date_end:
                break
            else:
                result.append((cur_start_date, cur_end_date))
                cur_start_date += time_delta

        return result

    @staticmethod
    def generate_daterange_with_target_range(
        target_range: Tuple[datetime.datetime, datetime.datetime],
        delta_date: int,
        drop_last=False,
        inclusive_right_bound=True,
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """
        Notice! The input target_range should be right bound exclusive (end date exclusive)!

        Generate daterange list with given target_range and deltadate.

        The generated date range is aligned with Modis delta_date days.
        Which means the delta_date between start datetime and first day of year is
        a multiple of delta_date.

        That is, for generated result: List[date_start, date_end]
        for each date_start:
            (date_start - datetime.datetime(year, 1, 1)) % delta_date = 0

        Parameters
        ----------
        target_range : Tuple[datetime.datetime, datetime.datetime]
            Target range, all generated date range will be intersected with the target_range.
            The Target range should be right bound exclusive (end date exclusive)!
        delta_date : int
            Delta date for each time period
        drop_last : bool, optional
            Whether to drop the last time slot of the year if end date of time slot is in the next year,
             by default False
        inclusive_right_bound : bool, optional
            Whether to include the right bound, if True, the right bound will subtract 1 microsecond
            from exclusive right bound, by default True

        Returns
        -------
        List[Tuple[datetime.datetime, datetime.datetime]]
            List of date ranges.
        """

        date_s: datetime.datetime
        date_e: datetime.datetime

        date_s, date_e = target_range
        date_s = date_s.replace(tzinfo=None)
        date_e = date_e.replace(tzinfo=None)
        target_range = (date_s, date_e)
        start_year = date_s.year
        end_year = date_e.year + 1

        daterange_list = []
        for year in range(start_year, end_year):
            daterange_list.extend(
                DateRangeGenerator.generate_daterange_with_deltadate(
                    year,
                    delta_date,
                    drop_last=drop_last,
                )
            )
        selected_range_list: List[
            List[datetime.datetime]
        ] = DateRangeGenerator.get_intersect_daterange(daterange_list, target_range)

        if inclusive_right_bound:
            for idx in range(len(selected_range_list)):
                cur_start_date, cur_end_date = selected_range_list[idx]
                selected_range_list[idx] = (
                    cur_start_date,
                    cur_end_date - datetime.timedelta(microseconds=1),
                )

        return selected_range_list

    @staticmethod
    def check_daterange_intersect(
        daterange1: Tuple[datetime.datetime, datetime.datetime],
        daterange2: Tuple[datetime.datetime, datetime.datetime],
    ) -> bool:
        """
        Check if two date range tuple intersect with each other.
        Both date range should be right bound exclusive (end date exclusive)!

        Parameters
        ----------
        daterange1 : Tuple[datetime.datetime, datetime.datetime]
            Date range tuple 1
        daterange2 : Tuple[datetime.datetime, datetime.datetime]
            Date range tuple 2

        Returns
        -------
        bool
            If two date range intersects, return True, otherwise False
        """
        date_s1, date_e1 = daterange1
        date_s2, date_e2 = daterange2
        if date_s1 >= date_e2 or date_s2 >= date_e1:
            return False
        return True

    @staticmethod
    def get_intersect_daterange(
        daterange_list: List[Tuple[datetime.datetime, datetime.datetime]],
        target_daterange: Tuple[datetime.datetime, datetime.datetime],
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """
        Extract a list of date ranges which intersects with the target_daterange.
        All daterange should be right bound exclusive (end date exclusive)!

        Parameters
        ----------
        daterange_list : List[Tuple[datetime.datetime, datetime.datetime]]
            List of date ranges to be selected from
        target_daterange : Tuple[datetime.datetime, datetime.datetime]
            Target daterange

        Returns
        -------
        List[Tuple[datetime.datetime, datetime.datetime]]
            The extracted date time list.
        """
        result = []
        for cur_daterange in daterange_list:
            if DateRangeGenerator.check_daterange_intersect(
                cur_daterange, target_daterange
            ):
                result.append(cur_daterange)

        return result

    @staticmethod
    def get_day_of_year_list(
        daterange_tuple: Tuple[datetime.datetime, datetime.datetime],
        use_part="start",
    ) -> int:
        """
        Basically, this function is not recommended,
        Use val.timetuple().tm_yday is much easier for day of year calculation.

        Given daterange tuple, return day of year of either start date or end date,
        based on the given use_part argument.


        Parameters
        ----------
        daterange_tuple : Tuple[datetime.datetime, datetime.datetime]
            date range tuple
        use_part : str, optional
            either 'start' or 'end', if 'start', the start day of daterange_tuple will be used. Otherwise the end day
            will be used, by default "start"

        Returns
        -------
        int
            Day of year

        Raises
        ------
        ValueError
            _description_
        """

        date_s, date_e = daterange_tuple
        if use_part.lower() == "start":
            return date_s.timetuple().tm_yday

        elif use_part.lower() == "end":
            return date_e.timetuple().tm_yday

        else:
            raise ValueError("use_part arguments should be in either start or end")

    @staticmethod
    def get_index_of_range(
        cur_date: datetime.datetime,
        delta_date: int,
        start_year: Optional[int] = None,
        start_index: int = 0,
    ) -> int:
        """
        Given a datetime value, and delta_time check the index of slot it belongs to.
        if start_index is given. Result index will be adjusted to the start_index.
        if start_year is given, the calculated time will based on the first day of the start_year.
        for example, cur_date= (2023,01,01), if start_year=2022 and delta_date=8, it will calculate from
        (2022,01,01).
        By default, 0 will be the start index.


        Parameters
        ----------
        cur_date : datetime.datetime
            The query datetime
        delta_date : int
            How many days for a time period (slot).
        start_year: int, optional
            The year of start time, if given, the first day of start_year will be set as the start time.
            by default None
        start_index: int, optional
            Start index of the result, by default 0
        """
        cur_year_start_date = datetime.datetime(cur_date.year, 1, 1)
        if start_year is not None:
            assert start_year <= cur_date.year
            cur_year_start_date = datetime.datetime(start_year, 1, 1)

        cur_delta_date = cur_date - cur_year_start_date
        index = cur_delta_date.days // delta_date
        return index + start_index

    @staticmethod
    def get_temporal_range_from_date(
        cur_date: datetime.datetime,
        delta_date: int,
        start_year: int = None,
        inclusive_right_bound=True,
    ) -> Tuple[datetime.datetime, datetime.datetime]:
        """
        Given a datetime value and delta_time, return the temporal range the cur_date belongs to.
        if start_year is given, the calculated time will based on the first day of the start_year.
        for example, cur_date= (2023,01,01), if start_year=2022 and delta_date=8, it will calculate from
        (2022,01,01).
        The date belonged temporal will be returned as Tuple[datetime.datetime, datetime.datetime]

        Parameters
        ----------
        cur_date : datetime.datetime
            The query datetime
        delta_date : int
            How many days for a time period (slot).
        start_year: int, optional
            The year of start time, if given, the first day of start_year will be set as the start time.
            by default None
        inclusive_right_bound : bool, optional
            Whether to include the right bound, if True, the right bound will subtract 1 microsecond
            from exclusive right bound, by default True
        """

        cur_year_start_date = datetime.datetime(cur_date.year, 1, 1)
        if start_year is not None:
            assert start_year <= cur_date.year
            cur_year_start_date = datetime.datetime(start_year, 1, 1)
        cur_delta_date = cur_date - cur_year_start_date
        index = cur_delta_date.days // delta_date
        start_delta_date = index * delta_date
        new_start_date = cur_year_start_date + datetime.timedelta(days=start_delta_date)
        new_end_date = new_start_date + datetime.timedelta(delta_date)
        if inclusive_right_bound:
            new_end_date = new_end_date - datetime.timedelta(microseconds=1)
        return [new_start_date, new_end_date]


if __name__ == "__main__":

    print("\nGenerated daterange list with target range and delta_date\n")
    print("Notice! the target_range input should be right bound exclusive!\n")

    print(
        "Example 1, generate 8 days range list with right bound inclusive format results:\n"
    )
    result = DateRangeGenerator.generate_daterange_with_target_range(
        target_range=(datetime.datetime(2021, 1, 1), datetime.datetime(2021, 2, 1)),
        delta_date=8,
        drop_last=False,
        inclusive_right_bound=True,
    )
    for val in result:
        print(val)

    print("\n *********** \n")
    print(
        "Example 2, generate 8 days range list with right bound exclusive format results:\n"
    )
    result = DateRangeGenerator.generate_daterange_with_target_range(
        target_range=(datetime.datetime(2021, 1, 1), datetime.datetime(2021, 3, 1)),
        delta_date=8,
        drop_last=False,
        inclusive_right_bound=False,
    )
    for val in result:
        print(val)

    print("\n *********** \n")
    print("Example 3, generate 8 days range with drop_last True:\n")

    result = DateRangeGenerator.generate_daterange_with_target_range(
        target_range=(datetime.datetime(2021, 12, 15), datetime.datetime(2022, 1, 5)),
        delta_date=8,
        drop_last=True,
        inclusive_right_bound=False,
    )
    for val in result:
        print(val)

    print("\n *********** \n")
    print("Example 4, generate 8 days range with drop_last False:\n")

    result = DateRangeGenerator.generate_daterange_with_target_range(
        target_range=(datetime.datetime(2021, 12, 15), datetime.datetime(2022, 1, 5)),
        delta_date=8,
        drop_last=False,
        inclusive_right_bound=False,
    )
    for val in result:
        print(val)

    print("\n *********** \n")
    print("Example 5, get_index_of_range:\n")
    result = DateRangeGenerator.get_index_of_range(
        datetime.datetime(2022, 1, 3), 8, start_year=2021, start_index=1
    )
    print(result)

    print("\n *********** \n")
    print("Example 6, get_temporal_range_from_date:\n")
    result = DateRangeGenerator.get_temporal_range_from_date(
        datetime.datetime(2022, 1, 3), 8, start_year=2021, inclusive_right_bound=True
    )
    print(result)
